breaking: the effect-stack lifecycle (#58): transports own their link, options, and sizing - #144
JPHutchins wants to merge 21 commits into
Conversation
`SMPUDPTransport.max_unencoded_size` overrode the base implementation with the MSS alone, so the MCUmgr parameters that `SMPClient` reads on connect never reached the UDP transport: `initialize(buf_size)` stored the value and nothing read it. Zephyr's UDP SMP transport receives each request as a single datagram into one MCUmgr buffer of `CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE` bytes, the value advertised as `buf_size`. A request larger than that cannot be received. The defaults (2048-byte buffer, 1500-byte MTU) hide this. A build that lowers the buffer, e.g. to its 384-byte non-UDP default, does not. The payload is now `min(MSS, buf_size)`. Before the params are known, `buf_size or mtu` makes that the MSS, so nothing changes for a server that does not advertise them. Verified: `camas check` green; the new parametrized test fails on the 384-byte case without the fix. https://github.com/zephyrproject-rtos/zephyr/blob/70be2ff0b565a3313128f5577f51cfeb3ebcf602/subsys/mgmt/mcumgr/grp/os_mgmt/src/os_mgmt.c#L551-L554 https://github.com/zephyrproject-rtos/zephyr/blob/70be2ff0b565a3313128f5577f51cfeb3ebcf602/subsys/mgmt/mcumgr/transport/Kconfig#L33-L58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(udp): cap the payload at the server's advertised buffer
| """ | ||
|
|
||
| from smpclient.transport.serial.encoded import Auto as Auto | ||
| from smpclient.transport import Auto as Auto |
There was a problem hiding this comment.
Why re-export these?
There was a problem hiding this comment.
The existing re-exports were needed becuase of the encoded/unencoded split
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins. It answers this thread for the force-pushed revision of the PR.
These re-exports are gone in the new revision. smpclient/transport/serial/__init__.py no longer re-exports any shared smpclient.transport type. Auto is imported from smpclient.transport, as are the shared BufferSize and Unfragmented.
What it does export are serial's own types, defined in its encoded, unencoded, and common submodules. That is the same split-driven reason as before: serial's own BufferSize (unchanged from main), plus the new SerialOptions and SerialPort from common.
|
Warning LLM Disclosure This review was authored by Read it as a self-review. The implementer and the Summary
Bugs to fix (9), each with a proposed fix
Smaller items:
Your decisions (4)A. The cost of the timeout policy (
B. The sequence number of the params read (review #2 and #12, advisor). The read hardcodes sequence 0, outside the client's injectable sequence space, which is the principle smp#71 set.
C. Naming (your call during review). Rename the concept to
The prefixes are there because the console and raw transports share the D. Split the PR? Most of the churn is forced by the design:
What could come out, if you want a smaller PR:
Public surface you haven't approved yet
Pushed back on (1)
Next: once A–D are answered, one fix commit for bugs 1–9 and the smaller items, then the naming rename, then re-gate and re-run integration. 🤖 Generated with Claude Code |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…request
A verbatim move with no behavior change: the TypeVars, the `success`/`error`
narrowers, `wrapping_sequence`, the validation diagnostics, and the body of
`SMPClient.request`, which becomes `_request.exchange(transport, request,
sequence, timeout_s)`. `SMPClient.request` delegates to it, and `smpclient`
re-exports every public name unchanged.
The next commit needs this: a transport reads the server's MCUmgr parameters
while it connects, before any `SMPClient` exists, so the exchange must sit below
the client. Review with `git show --color-moved`; 252 of the 284 changed lines
are moved.
`smpclient` imports `_request` as a module (`from smpclient import _request`),
so the package attribute stays the submodule. An `import ... as _request` of the
function would shadow it and break `mock.patch("smpclient._request.…")`. The
function is named `exchange` so that its `request` parameter doesn't shadow it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lient never opens a link `SMPClient(transport, address, timeout_s)` becomes `SMPClient(transport, *, timeout_s, sequence)`. `connect()`, `disconnect()`, `address`, `__aenter__`/`__aexit__` and `_initialize()` are removed. The client only sends and receives over a transport that is already open. It holds the `SMPTransport` Protocol, which no longer has `connect` or `disconnect`, so it can't control the transport's side effects. Each transport now takes its address in the constructor (#58), along with `connect_timeout_s` and `sequence`. `connect()` and `disconnect()` take no arguments, and their bodies are unchanged: - serial: the old `connect` body becomes `_open()`; `connect()` is `_open()` then `negotiate()` - UDP: `SMPUDPTransport(address, port=1337, *, mtu, ...)`. The port is a real parameter, the point of #58. - bleak: `connect()` wraps `_connect(address, timeout_s)`, which is unchanged - bumble: `connect()` is the old flow, reading the address from `self`. `use_connection` becomes `borrow`, and the module helper `borrowed_connection()` becomes the method `borrowed()`. A private base, `_ConnectableTransport`, adds the encouraged bracket, `async with transport.connected():`. It connects, yields the transport, and disconnects best-effort. The primitives remain for lifetimes a lexical scope can't express, e.g. a standing link held for an application's lifetime. The MCUmgr parameters read moves out of `SMPClient._initialize` into the transport's `negotiate()`, with the same warnings and the same fallback on an error or a timeout (`_request.read_mcumgr_parameters`). `negotiate()` runs inside `connect()` and `borrow()`, and it is public, for re-negotiating: the integration harness uses it after a server boots, and a borrowed link can negotiate at all. Behavior is unchanged here: the read is still unconditional. The next commit makes it conditional on each transport's fragmentation strategy. `connect()` is all-or-nothing on every transport: a failed or cancelled negotiation closes the link it just opened. Tests move to address-first constructors and argument-free `connect()`. A `skip_negotiation` fixture answers the params read with `None`, so tests that drive `connect()` over mocked I/O don't wait for a server. The integration harness enters `transport.connected()` and re-negotiates after the echo wait, where it used to call `client._initialize()`. Its skip for a UDP fixture on a non-default port is gone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e only when asked Each transport now declares how it sizes SMP messages with its own fragmentation strategy union, and reads the server's MCUmgr parameters only when that strategy asks for them. A pinned strategy never issues the read, so a server without the params command, like mcuboot serial recovery, never sees it. The shared vocabulary lives in `smpclient.transport`: - `Auto`: read `buf_size` while connecting. On a timeout or an error response, warn and fall back to the transport's conservative default. - `BufferSize(buf_size)`: a known server buffer; nothing is read. - `Unfragmented`: GATT only. Like `Auto`, but one message per write, for a server built without `CONFIG_MCUMGR_TRANSPORT_BT_REASSEMBLY`. The per-transport unions use the prefixed names: - `SerialFragmentationStrategy = Auto | BufferSize | BufferParams` (serial's own `BufferSize(buf_size, line_length)` and `BufferParams` are unchanged from main) - `RawSerialFragmentationStrategy = Auto | BufferSize` - `UDPFragmentationStrategy = Auto | BufferSize`, always capped at the MSS - `GATTFragmentationStrategy = Auto | Unfragmented | BufferSize`, shared by `SMPBLETransport` and `SMPBumbleTransport` through a `_GATTTransport` mixin `SMPTransport.initialize()` and `_smp_server_transport_buffer_size` are gone. Each transport's `negotiate()` matches its strategy exhaustively and stores `_negotiated_buf_size`; `max_unencoded_size` is derived from the strategy. Breaking: - `smpclient.transport.serial.FragmentationStrategy` is renamed `SerialFragmentationStrategy`. - `Auto` moves to `smpclient.transport`, since every transport uses it. - `SMPSerialRawTransport(port, mtu=384)` becomes `SMPSerialRawTransport(port, fragmentation_strategy=Auto())`; pin the old behavior with `BufferSize(384)`. Its `mtu` now reports `max_unencoded_size`, one whole message. - The serial "pinned size exceeds the server's buffer" warnings are removed: a pinned strategy no longer reads the parameters it would compare against. Tests: `tests/support.py` adds `advertise(buf_size)`, which patches the params read, and `negotiated(transport, buf_size)`. Each transport tests that a pinned strategy never reads, and how `Auto` and `Unfragmented` cap the size. The integration raw transport defaults to `Auto()`, so the suite exercises negotiation against the real fixtures (229 passed, 101 skipped, the same as before). Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The eleven pyserial keyword arguments are replaced by one
`options: SerialOptions = SerialOptions()` on `SMPSerialTransport`
(all three constructor overloads and the implementation) and on
`SMPSerialRawTransport`. The settings are declared once, in
`smpclient.transport.serial.common`, and exported from
`smpclient.transport.serial`; they are no longer repeated across the
four encoded signatures, the raw signature, and the base.
SMPSerialTransport(port, baudrate=9600)
SMPSerialTransport(port, options=SerialOptions(baudrate=9600))
`test_serial_options_lock_pyserial` locks the field names, their order,
and their defaults to `inspect.signature(serial.Serial)`. The one
deliberate difference is `baudrate`: 115200 here, 9600 in pyserial.
pyserial is effectively unmaintained, so drift isn't expected, but the
test fails loudly if it happens. A renamed field and a changed default
were each confirmed to fail the test.
Refs #58
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… flush fails `_open()` now runs pyserial's blocking `open()` and `reset_input_buffer()` in `asyncio.to_thread`, so a slow open (USB CDC ACM still enumerating, for example) no longer stalls the event loop. The leak, which is pre-existing on main: `reset_input_buffer()` was inside the retry `try`. When the flush raised `SerialException` after a successful `open()`, the loop called `open()` again on the open port. pyserial refuses that with another `SerialException`, so the loop spun until `connect_timeout_s` and raised `TimeoutError`, leaving the first fd open. Only `open()` is retried now (`try/except/else`), and `connect()` wraps `_open()` too in its all-or-nothing `except (Exception, CancelledError): close()`. A failed flush, or a cancellation during the open, closes the port and re-raises. A cancellation that lands while the worker thread is still inside `open()` cannot interrupt that thread. The best-effort `close()` runs either way, and is a no-op if the open hadn't finished. Tests: `test_connect_closes_the_port_when_the_flush_fails` fails on the previous code. `test_connect_closes_the_port_when_cancelled_while_negotiating` covers the cancel path. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cket chardev `_SerialTransportBase` gains the same borrow primitives the GATT transports have: - `await t.borrow(port)` adopts a caller's open `SerialPort`, clears the framing state, then runs `negotiate()`. It is all-or-nothing: if negotiation raises or is cancelled, the transport reverts to its own port. - `async with t.borrowed(port):` wraps borrow and disconnect in a bracket. - `disconnect()` returns a borrowed port without closing it; the acquirer releases. It still closes the transport's own `Serial`. `SerialPort` is the Protocol for the four members the transports use: `port`, `out_waiting`, `write`, and `read_all`. `serial.Serial` satisfies it, and so does a `serial_for_url` port that reports `out_waiting`. Both it and `SerialOptions` are exported from `smpclient.transport.serial`. Which port is live is a sum type, `_Link = _Owned | _Borrowed(port)`. `_conn` becomes a property that matches on it, and `_serial` is the transport's own `Serial`, still constructed closed in `__init__`. The unit tests' `t._conn.<attr> = MagicMock(...)` assignments still land on the owned mock, so they keep working unchanged. Integration harness: the `QemuSocketSerialTransport` and `QemuSocketSerialRawTransport` subclasses are gone. They overrode `_open` and replaced the `Final` `_conn` with `object.__setattr__`. Now `socket_link(transport, url)` opens the emulator's `socket://` chardev (paced for the raw transport), lends it with `transport.borrowed()`, and closes it on exit, so the suite drives the real public API. `ConnectedServer` carries its link as an `AsyncExitStack`, and `reboot_into_recovery` releases it with `link.aclose()` before opening the recovery link it is handed. Integration: 229 passed, 101 skipped, the same as before, with every socket fixture going through `borrowed()`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SMPBLETransport` gains the borrow primitives the other transports have: - `await t.borrow(client)` adopts a caller's connected `BleakClient`, finds the SMP characteristic, sizes writes to the link, subscribes, and then runs `negotiate()`. It is all-or-nothing: on failure or cancellation the transport returns the client and re-raises. - `async with t.borrowed(client):` wraps borrow and disconnect in a bracket. - `disconnect()` on a borrowed client unsubscribes and never disconnects it; the acquirer releases. The `stop_notify` is bounded by `connect_timeout_s`, and a failure is logged rather than raised, so returning the client can't hang or mask the caller's error when the owner has already dropped the link. The part of `_connect()` after the link comes up is now `_start_smp()`, shared by connect and borrow, and it clears the receive buffer. Ownership is a sum type, `_Link = _Owned | _Borrowed(client)`. `_active_client` matches on it; `_client` stays the transport's own client, so the unit tests that assign it keep working. Disconnect detection: bleak takes `disconnected_callback` only when the client is constructed, and the owner holds it. So `_until_disconnected()` waits on the transport's event when it owns the client, and polls `client.is_connected` every 100 ms when it borrows one. The poll runs only inside a receive or GATT wait. There is no watcher task, so nothing outlives the primitive that started it. `_notify_or_disconnect` now reaps its two sub-tasks in a `finally`, like `_await_or_disconnect`. Before, cancelling a waiting `receive()` leaked both tasks; with a borrowed client, that would leave a poll loop running for as long as the owner's link stayed up. The old `except CancelledError: pass` around the reaping `gather` also swallowed a cancellation of the waiter itself; a positional `gather(..., return_exceptions=True)` doesn't. `test_borrowed_receive_leaves_no_task_polling_when_cancelled` fails without the `finally`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SMPBLETransport(address, bluez=BlueZClientArgs(adapter="hci1"))`
scans for the device and connects to it on that adapter, instead of
bleak's default. The options are bleak's own type, so the
`BlueZClientArgs` passes to `BleakClient`, and its keys, a subset of
`BlueZScannerArgs`, pass to `find_device_by_address` and
`find_device_by_name`. `SMPBLETransport.scan()` takes
`bluez: BlueZScannerArgs` too. Both default to `{}`, which is bleak's
default adapter, so behavior is unchanged.
This sits beside the existing `winrt=WinRTClientArgs(...)`, which
already carries #90's `use_cached_services`. bleak is pinned `>=3.0.2`,
and the BlueZ adapter args date from 3.0.
Closes #103
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SMPBumbleTransport.bonded_devices()`, `.clear_bond(address)`, and
`.clear_bonds()` become module functions in `smpclient.transport.bumble`:
await bonded_devices(keystore=..., host_address=...)
await clear_bond(address, keystore=..., host_address=...)
await clear_bonds(keystore=..., host_address=...)
They only ever read the transport's `keystore` and `host_address`, the
keystore namespace, and never its link. So listing or clearing bonds
no longer means building a transport for a device address you don't
plan to connect to. The defaults match the transport's, `Tempfile()`
and `DEFAULT_HOST_ADDRESS`, so a call with none of the options sees the
same bonds a default transport writes. The private
`_standalone_keystore()` helper is gone.
The functions had no tests; `test_bond_functions_manage_the_hosts_bonds`
seeds a keystore, then covers list, per-host isolation, clearing one
bond, and clearing all.
Refs #58
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ibe only ourselves Three lifecycle gaps from the #144 review: - A cancelled `connect()` leaked its partial state. The teardown arm was `except Exception`, which `CancelledError` bypasses, so a cancel mid `device.connect()` left the state at `Connecting` with the HCI transport open. Every later `connect()` then raised "called while in state Connecting". A `CancelledError` arm now tears down and re-raises, logged at debug: a cancel is the caller's decision, not an error. This is pre-existing on main. - `borrow()` was not all-or-nothing. If `negotiate()` raised or was cancelled, the transport stayed `ConnectedBorrowed`, subscribed, with its disconnection listener attached. It now returns the connection (`disconnect()` → `_teardown_borrowed`) and re-raises, like serial and bleak `borrow()`. - Returning a borrowed connection called `smp_characteristic.unsubscribe()` with no subscriber. bumble reads that as "drop every subscriber" and writes the CCCD to zero, cutting off the owner's own notifications on the shared characteristic. It now passes `self._on_notification`. bumble keys subscriber proxies by the subscriber, and a bound method compares equal each time it's looked up, so only this transport's proxy is removed, and the CCCD is cleared only if no subscriber is left. The owned teardown still unsubscribes everything; it owns the whole link. Each new test fails on the previous code. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ises SMPTransportDisconnected `_Link` is now `_Closed | _Owned(client) | _Borrowed(client)`, and the separate `_client` attribute is gone. `connect()` creates `_Owned(BleakClient(...))`; `borrow()` makes `_Borrowed(client)`; and `disconnect()` ends in `_Closed()` from any state, so it stays idempotent. This closes a gap that was also on `main`. Once a transport that only ever borrowed had returned its client, `disconnect()` switched back to the old `_Owned()` marker, and `_active_client` read a `self._client` that only `connect()` assigns. `send()`/`receive()` raised `AttributeError: _client` instead of `SMPTransportDisconnected`; the same was true on `main` for a transport that never connected. With the client inside the variant, "no client" is its own case: - `_active_client` raises `SMPTransportDisconnected` on `_Closed`. - `_until_disconnected` returns at once on `_Closed`. - `_best_effort_disconnect` delegates to `disconnect()`, dropping its defensive `getattr`. - `_set_disconnected_event` still rejects a callback from a client other than the owned one. After our own `disconnect()` the link is `_Closed`, so bleak's callback for that disconnect is accepted. Tests inject `t._link = _Owned(client)`, or read the owned client with `_owned_client(t)`, instead of assigning `t._client`. Four sizing tests dropped a client assignment they never used. `test_a_returned_borrow_raises_disconnected` fails on the previous code with the `AttributeError`, and `test_disconnect` now also checks idempotence and a send after close. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
a76b962 to
d35531e
Compare
|
Warning LLM Disclosure This comment was authored by Force-pushed the new revision:
At the tip:
|
…al's settings everywhere
The lock test read `inspect.signature(serial.Serial)`, which only works
where `serial.Serial` inherits `SerialBase.__init__`: POSIX. On Windows,
`serial.Serial` is `serialwin32.Serial`, whose
`__init__(self, *args, **kwargs)` sets up the overlapped handles and
forwards to `SerialBase.__init__`. The signature there reads `('args',)`,
which failed every Windows job on #144.
`SerialBase` in `serial.serialutil` declares the settings on every
platform, so the test reads its signature, and first asserts that
`serial.Serial` subclasses it. The transport already passes
`**options._asdict()` through `serial.Serial` to `SerialBase`.
Refs #58
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JPHutchins
left a comment
There was a problem hiding this comment.
Going the right direction. Overall poor code quality, needs some review and revision. Better typing, less mutation, etc etc.
| sequence: The SMP sequence space the MCUmgr parameters read draws from; | ||
| defaults to `wrapping_sequence()`. |
There was a problem hiding this comment.
Yu say it defaults to wrapping_sequence, yet you default it to None in the sig
There was a problem hiding this comment.
The correct approach is for it to be structural (type level) not "prose".
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Fixed in e4e8228. The default is now in the signature, sequence: Callable[[], Iterator[u8]] = wrapping_sequence, on every transport and on SMPClient, and the "defaults to" prose is gone. It's a factory rather than an iterator because a default iterator is evaluated once at definition time and would be shared by every instance.
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Agreed; it's structural now (e4e8228): sequence: Callable[[], Iterator[u8]] = wrapping_sequence.
| self._fragmentation_strategy = fragmentation_strategy | ||
| self._connect_timeout_s = connect_timeout_s | ||
| self._sequence = _request.wrapping_sequence() if sequence is None else sequence |
There was a problem hiding this comment.
Are these final?
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
They are now (e4e8228). As Protocol members they couldn't be: a Final assignment in the implementer conflicts with the writable Protocol attribute. _ConnectableTransport is now a concrete base, generic over the strategy union, and its __init__ sets _fragmentation_strategy, _connect_timeout_s, and _sequence as Final.
|
|
||
|
|
||
| async def bonded_devices( | ||
| *, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS |
There was a problem hiding this comment.
why kwargs only?
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
No reason; dropped in 7c91b0b. bonded_devices, clear_bond, and clear_bonds take their arguments positionally too.
| self._connect_timeout_s = connect_timeout_s | ||
| self._sequence = _request.wrapping_sequence() if sequence is None else sequence |
There was a problem hiding this comment.
Are these final?
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Yes, now (e4e8228), through the concrete base's __init__. _port is gone entirely (f6678a1): the port is an argument to connect(port).
| SerialFragmentationStrategy: TypeAlias = Auto | BufferSize | BufferParams | ||
| """How `SMPSerialTransport` sizes SMP messages: `Auto`, `BufferSize`, or `BufferParams`. | ||
|
|
||
| With `Auto`, connecting reads the server's `buf_size` (the decoded reassembly buffer) and | ||
| the transport sends messages up to `buf_size - 4`, filling that buffer; until the parameters | ||
| are read, or if the server doesn't provide them, it assumes a conservative line budget. | ||
| """ |
There was a problem hiding this comment.
llm doc slop - restates the code and other doc strings
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Cut in 7c91b0b: SerialFragmentationStrategy is one line. So are the raw and UDP aliases. The constructors' fragmentation_strategy Args no longer repeat the union.
| def initialize(self, smp_server_transport_buffer_size: int) -> None: # pragma: no cover | ||
| """Initialize the `SMPTransport` with the server transport buffer size. | ||
|
|
||
| Args: | ||
| smp_server_transport_buffer_size: The SMP server transport buffer size, in 8-bit bytes. | ||
| """ | ||
| self._smp_server_transport_buffer_size = smp_server_transport_buffer_size |
There was a problem hiding this comment.
Isn't negotiate required now?
There was a problem hiding this comment.
Errr, I guess not, since client doesn't call it.
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Right, it isn't: negotiate() is abstract on _ConnectableTransport and absent from SMPTransport, so SMPClient can't call it. connect() and borrow() run it, and it stays public for re-negotiating a live link, for example after a boot race or a firmware update.
| async def connect(self) -> None: # pragma: no cover | ||
| """Open the link, then `negotiate()`.""" | ||
| ... | ||
|
|
||
| async def disconnect(self) -> None: # pragma: no cover | ||
| """Close the link.""" | ||
| ... |
There was a problem hiding this comment.
Add note to not use them; dangerous loss of side effect control
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Added. The _ConnectableTransport docstring now reads: "Open a link with the connected() or borrowed() bracket: the bare connect(), borrow(), and disconnect() primitives give up the bracket's guarantee that the link is released on error and on cancellation." Each primitive's docstring ends in "prefer connected()" or "prefer borrowed()" (e4e8228, f6678a1). The wording says prefer rather than don't use; say if you want it stronger.
| winrt: WinRT backend arguments, e.g. `use_cached_services`. | ||
| bluez: BlueZ backend arguments, e.g. the `adapter` to scan and connect with. |
There was a problem hiding this comment.
You're telling me that we accpet both? On macos too? At the same time. FUCKING GARBAGE. This is a sum type.
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Agreed; it's one sum type now (eab0363): backend: BleakBackend = PlatformDefault(), where BleakBackend = PlatformDefault | BlueZ | WinRT, and exactly one backend's options reach bleak. The variants wrap bleak's own TypedDicts, BlueZ(BlueZClientArgs(adapter="hci1")), so bleak's schema stays the single source of truth. The cost is one extra layer compared with a flat BlueZ(adapter="hci1"); say if you'd rather have the flat form.
| self._address: Final = address | ||
| self._fragmentation_strategy = fragmentation_strategy | ||
| self._connect_timeout_s = connect_timeout_s | ||
| self._sequence = _request.wrapping_sequence() if sequence is None else sequence |
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Yes (e4e8228): the config through the base's __init__, and _buffer/_notify_condition/_disconnected_event/_backend in the constructor.
| return summary, details | ||
|
|
||
|
|
||
| class SMPClient: |
There was a problem hiding this comment.
Should be generic over the transport, so that users can access the transport in a type safe way.
There was a problem hiding this comment.
Warning
LLM Disclosure
This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins, addressing her review of 5d58791.
Done in 7bad118: SMPClient(Generic[TTransport]) with TTransport = TypeVar("TTransport", bound=SMPTransport), and a transport property typed TTransport. SMPClient(SMPSerialTransport()).transport.read_serial() type-checks with no cast; ICUploadClient is generic too. tests/test_generics_typing.py asserts it under mypy and pyright.
Per review on #144: the overloads existed only for backwards compatibility, and this is the breaking release, so they go now rather than in the follow-up planned earlier. `SMPSerialTransport(port, fragmentation_strategy=Auto(), *, ...)` is now the only signature. Removed: - the three `__init__` overloads, including the two `@deprecated` ones for `max_smp_encoded_frame_size`/`line_length`/`line_buffers` - `_LegacyParams` and `_ResolvedStrategy` - `_resolve_fragmentation_strategy` - the `_LEGACY_FRAME_SIZE` constant and `_LEGACY_PARAMS_DEPRECATION` - the `_LegacyParams` match arms in the sizing properties - the six tests covering the 7.1.0 reproduction The constructor now validates the strategy directly. `_LEGACY_LINE_BUFFERS` survives as `_AUTO_LINE_BUFFERS`, the line buffers `Auto` assumes before the server's parameters are read. `typing_extensions.deprecated` is no longer used, and the pyproject comment about typing-extensions' minimum no longer names it. Migration: `max_smp_encoded_frame_size=n, line_length=l, line_buffers=b` becomes `BufferParams(line_length=l, line_buffers=b)`, or better, `BufferSize(buf_size=...)` for the server's decoded buffer. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… no Optional sizing state Per review on #144 ("Are these final?", "you say it defaults to wrapping_sequence, yet you default it to None in the sig", "less mutation"): - `_ConnectableTransport` is now a concrete base, generic over the transport's fragmentation strategy union. Its `__init__` sets `_fragmentation_strategy`, `_connect_timeout_s`, and `_sequence` as `Final`. As Protocol members they could not be `Final`: a `Final` assignment in an implementer conflicts with a writable Protocol attribute. `connect`/`disconnect`/`negotiate` are `@abstractmethod`, and their docstrings point at the `connected()` bracket. - `sequence: Iterator[u8] | None = None` becomes `sequence: Callable[[], Iterator[u8]] = wrapping_sequence` on every transport and on `SMPClient`, so the signature states the default. A factory rather than an iterator, because a default iterator is evaluated once at definition time and would be shared across instances. The "defaults to `wrapping_sequence()`" prose is gone. - `_negotiated_buf_size: int | None` is gone. The configured strategy stays `Final`, and one slot, `_sizing`, holds the strategy as `negotiate()` resolved it. `Auto` resolves to `BufferSize(n)` when the server advertises `n`; GATT `Unfragmented` resolves to `BufferSize(min(mtu, n))`. An unadvertised read resets to the unresolved strategy, so a re-negotiation never keeps a stale size from an earlier server. Every sizing property now matches `_sizing` with no `is None` branches; `Auto` there only means "the server advertised nothing". - Attributes that are never reassigned are `Final`: UDP's `_mtu`, bleak's `_buffer`/`_notify_condition`/`_disconnected_event`/`_winrt`, and `SMPClient._timeout_s`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the constructor Per review on #144 ("Port wouldn't be required if we achieve via SMPSerialTransport.borrowed()... right?"): a transport that only borrows never needed a port or address. The constructor now holds only config: the fragmentation strategy, options, timeouts, and sequence. The target belongs to the primitive that opens a link. async with SMPSerialTransport(BufferSize(1024)).connected("/dev/ttyACM0") as t: ... async with SMPSerialTransport().borrowed(open_port) as t: ... async with SMPUDPTransport().connected("192.168.1.1", 1337) as t: ... async with SMPBLETransport().connected("AA:BB:CC:DD:EE:FF") as t: ... async with SMPBumbleTransport(hci="usb:0").connected("AA:BB:CC:DD:EE:FF") as t: ... - `connect(target)` and `connected(target)` are defined per transport: serial `port`, BLE/bumble `address`, UDP `address, port=1337`. The signatures differ, so `_ConnectableTransport` no longer declares `connect`. What each bracket shares is `_released_on_exit()`, which yields the link and releases it best-effort on every exit. Both `connected()` and `borrowed()` use it. - The primitives' docstrings point at their bracket, and the base class docstring says what the bare primitives give up. - The positional constructor arguments are now the ones `main` had: serial and raw serial take `fragmentation_strategy`; UDP takes `mtu`. - Tests, the integration harness (`_link`, `socket_link`, the recovery and line-length tests), the examples, the `SMPClient` docstring example, and the bumble CLI all pass the target to `connect`/`connected`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… at once Per review on #144 ("You're telling me that we accept both? On macos too? At the same time... This is a sum type."): `winrt=` and `bluez=` could both be passed, and neither means anything on macOS. They become one `backend` argument: BleakBackend = PlatformDefault | BlueZ | WinRT SMPBLETransport(backend=BlueZ(BlueZClientArgs(adapter="hci1"))) SMPBLETransport(backend=WinRT(WinRTClientArgs(use_cached_services=True))) SMPBLETransport() # PlatformDefault() The variants wrap bleak's own `BlueZClientArgs`/`WinRTClientArgs`, so bleak's option schema stays the single source of truth. `_bluez_args` and `_winrt_args` each match the backend exhaustively into the `bluez=`/`winrt=` that `BleakClient` and `BleakScanner` take; the backend that wasn't chosen gets `{}`. `SMPBLETransport.scan()` takes the same `backend` instead of `bluez: BlueZScannerArgs = {}`, which also drops a mutable default argument. `test_connect_passes_bleak_only_the_chosen_backend` checks, for each variant, what the scanner and the client receive. Closes #103 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Per review on #144 ("Should be generic over the transport, so that users can access the transport in a type safe way."): TTransport = TypeVar("TTransport", bound=SMPTransport) class SMPClient(Generic[TTransport]): ... @Property def transport(self) -> TTransport: ... `SMPClient(SMPSerialTransport())` is an `SMPClient[SMPSerialTransport]`, so `client.transport.read_serial()` type-checks with no cast. `ICUploadClient` is generic over the same `TTransport`. `SMPClient.__init__` gains its missing `-> None`. `tests/test_generics_typing.py` asserts the type of `client.transport` for both classes under mypy and pyright. Typing the property as plain `SMPTransport` fails that check. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… positional bond args Per review on #144 ("llm doc slop - restates the code and other doc strings", "why kwargs only?"): - The strategy aliases no longer list their own members or restate the sizing rules the properties implement. `SerialFragmentationStrategy`, `RawSerialFragmentationStrategy`, and `UDPFragmentationStrategy` are each one line. The constructors' `fragmentation_strategy` Args no longer repeat the union. - `_request.exchange` and `_request.read_mcumgr_parameters` are private helpers, so their docstrings are one line. `exchange` was a copy of `SMPClient.request`'s docstring, which remains the documented contract. - The nested `match` on the params read (`int | None`) ended in a bare capture, which rules out an `assert_never` arm. Each now matches `case int() as buf_size:` and closes with `case _ as unreachable: assert_never(unreachable)`. That covers all five transports' `negotiate()`, including encoded serial's guarded arm. - `bonded_devices`, `clear_bond`, and `clear_bonds` drop the `*`. There is no ambiguity for keyword-only to guard against. - Test comments no longer mention the removed 7.1.0 params. - `serial.common` reuses `smpclient.transport._TStrategy` instead of redeclaring an identical TypeVar. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Warning LLM Disclosure This comment was authored by Fast-forwarded
The two calls you made first:
Diff against the base: src +1056/−801, tests +812/−484. The src deletions rose because the 7.1.0 params are gone. |
Warning
LLM Disclosure
This PR was authored by
claude-opus-5-5[1m]on behalf of @JPHutchins. @JPHutchins found the first revision far more invasive than expected. She asked for a smaller diff on the idiom she settled on: transports are valid while closed, standardconnect()/disconnect()/borrow()primitives with theconnected()/borrowed()brackets encouraged, andSMPClientunable to control transport side effects. Her review of5d58791then asked for better typing, less mutation, and conformance with her coding guidelines; the fix-up commits froma1c2b16on answer it.Note
Targets
screaming-goblin, the breaking branch (#143, epic intercreate/smpmgr#103). Implements the design from #58. Closes #90. Closes #103. It supersedes the first revision (a76b962) and its review.A transport is constructed closed and holds only config. The link target is an argument to the primitive that opens the link.
SMPClient(transport)is a plain object that can't open or close a link, and it keeps the transport's type.SMPTransport, whatSMPClientsees, is onlysend,receive,send_and_receive,mtu, andmax_unencoded_size. It has no lifecycle.connect(target)anddisconnect()on every transport, plusborrow(resource)on serial, bleak, and bumble. Theconnected(target)andborrowed(resource)brackets are the encouraged form. They release the link best-effort on every exit, including cancellation.connect()andborrow(), and through a publicnegotiate(). It sends the MCUmgr params read only when the fragmentation strategy asks,Auto()or GATTUnfragmented(), and it resolves that strategy into aBufferSize. A timeout or error response warns and falls back.Commits (each passes
camas checkon its own)40c6d36smpclient._request; no API changea353b5bSMPClientlosesconnect/disconnect/address/async with78c58ee*FragmentationStrategyunions sharingAuto/BufferSize/Unfragmented; conditionalnegotiate()9d00fd4options=SerialOptions(...)9a5eb94asyncio.to_thread; a failed input flush no longer leaks the fd (pre-existing onmain)c8ccc02borrow(port)/borrowed(port); the integration harness borrows itssocket://chardev03306eaborrow(client)/borrowed(client), pollingis_connected;receive()no longer leaks tasks on cancel0ecf44ceab03632bd7554bonded_devices/clear_bond/clear_bondsbecome module functionsbdc979econnect(); all-or-nothingborrow(); unsubscribe only this transportd35531eSMPTransportDisconnected5d58791SerialOptionstoSerialBase(Windows)5d58791a1c2b16e4e8228Finalconfig in a concrete generic base;sequence: Callable[[], Iterator[u8]] = wrapping_sequence; a resolved_sizingslot replaces_negotiated_buf_size: int | Nonef6678a1connect(target)/connected(target)eab0363winrt=/bluez=→ onebackend: BleakBackend = PlatformDefault() | BlueZ(...) | WinRT(...)7bad118SMPClientis generic over its transport:client.transportkeeps its type7c91b0bmatchcloses withassert_never, bond functions positionalBreaking changes
main)SMPClient(transport, address, timeout_s), thenasync with clientorclient.connect()async with Transport(...).connected(address) as t:, thenSMPClient(t, timeout_s=...)SMPClient.connect()/.disconnect()/.addressSMPClient.transportis the typed transportSMPTransport.connect(address, timeout_s)/.disconnect()/.initialize(buf_size)connect_timeout_sin the constructor and the target inconnect(target);initialize()becomesnegotiate()SMPSerialTransport(strategy, line_length, line_buffers, *, max_smp_encoded_frame_size, <pyserial kwargs>)+connect(port, timeout_s)SMPSerialTransport(strategy, *, options=SerialOptions(...)).connected(port); the 7.1.0 params are goneSMPSerialRawTransport(mtu=384)SMPSerialRawTransport(fragmentation_strategy=Auto());BufferSize(384)pins the old sizeSMPUDPTransport(mtu)+connect(address, timeout_s, port=1337)SMPUDPTransport(mtu, *, fragmentation_strategy=).connected(address, port=1337)SMPBLETransport(winrt=)+connect(address, timeout_s)SMPBLETransport(*, backend=, fragmentation_strategy=).connected(address); newborrowed(client)SMPBumbleTransport(hci=, ...)+connect(address, timeout_s)/use_connection(c)/borrowed_connection(t, c)SMPBumbleTransport(hci=, ...).connected(address)/.borrowed(c)SMPBumbleTransport.bonded_devices()/.clear_bond()/.clear_bonds()keystoreandhost_addresssequence: Iterator[u8] | None = Nonesequence: Callable[[], Iterator[u8]] = wrapping_sequencesmpclient.transport.serial.FragmentationStrategy/.AutoSerialFragmentationStrategy/smpclient.transport.AutoSerial's
BufferSize(buf_size, line_length)andBufferParams(line_length, line_buffers)are unchanged frommain.Decisions, for review
connect(port)/connected(port)open a link,borrow(resource)/borrowed(resource)adopt one, and a borrowing transport never names a port.connectsignatures differ per transport, soconnected()is per-transport too. What the brackets share is_released_on_exit().Finalconfig through a concrete base. As Protocol members,_fragmentation_strategy/_connect_timeout_s/_sequencecouldn't beFinal._ConnectableTransportis now a concrete base, generic over the strategy union, whose__init__sets them.Final. One_sizingslot of the same union holds whatnegotiate()resolved:Auto→BufferSize(n), GATTUnfragmented→BufferSize(min(mtu, n)), and back to the unresolved strategy when nothing is advertised. There is noint | Nonestate, and nois Nonebranch in the sizing code.wrapping_sequence. A default iterator would be evaluated once and shared across instances._Owned | _Borrowed(port); its ownSerialexists, closed, from construction. bleak uses_Closed | _Owned(client) | _Borrowed(client). bumble keeps its existing state machine.disconnect()returns a borrowed resource and never closes it.BleakBackend = PlatformDefault | BlueZ | WinRTwraps bleak's ownBlueZClientArgs/WinRTClientArgs, so bleak's schema stays the single source of truth; exactly one backend's options reach bleak. Bleak transport options #90 (use_cached_services) isWinRT(WinRTClientArgs(use_cached_services=True)).client.is_connectedat 100 ms, and only while a receive or GATT wait is running. bleak accepts a disconnect callback only at construction, and the owner holds it.borrow(), because its client class is private.main's pre-existing link fields (bleak's_smp_characteristic/_max_write_without_response_size, UDP's_is_ipv6) are unchanged.Verification
camas check(ruff, pydoclint, mypy, pyright, tests) green.camas check387 passed, 14 skipped; coverage 93.7% (floor 91%).camas matrixgreen on 3.10–3.14, andcamas test_integration229 passed, 101 skipped, the same as breaking: port to smp screaming-goblin (Frame[T] on msgspec) #137's baseline. Every socket-serial fixture, including serial recovery over all four framings, runs through the publicborrowed()API.AttributeErrorafter a borrow returnsconnect()borrow()negotiation failureSerialOptionslock test was confirmed to fail on a renamed field and on a changed default. Theclient.transporttype assertion fails if the property is typed as plainSMPTransport.🤖 Generated with Claude Code